In diesem kurzen Tutorial möchte ich am Beispiel der Wahlprogramme der derzeit im Bundestag vertretenen Parteien für die Bundestagswahl 2021 zeigen, wie man mit Hilfe von R einfache Keyword-Analysen durchführen kann. Wer dem Tutorial wirklich folgen möchte, muss Grundkenntnisse in R mitbringen - einen ersten Einstieg bietet beispielsweise mein entsprechendes Tutorial hier ebenso wie zahlreiche andere Ressourcen, die online zu finden sind. Wer einfach nur den Prozess verfolgen und die Ergebnisse sehen möchte, kann aber gern mitlesen bzw. den Code copy&pasten, ohne genauer verstehen zu müssen, was “hinter den Kulissen” passiert.
Wir laden zunächst ein paar Zusatzpakete: pdftools erlaubt uns, PDF-Dateien einzulesen; die “tidyverse”-Paketfamilie bietet einige nützliche Funktionen für die Datenaufbereitung und -analyse, und das “tidytext”-Paket vereinfacht die Arbeit mit n-Grammen (dazu später mehr). Das Paket “wordcloud” erlaubt die Erstellung von Wortwolken, was wir gegen Ende des Tutorials tun wollen – wissenschaftlich gesehen kein besonders erkenntnisträchtiges Visualisierungsformat, aber durchaus geeignet, um einen ersten Eindruck von den Daten zu gewinnen, und daher auch in der populärwissenschaftlichen Vermittlung von (sprach-)wissenschaftlichen Forschungsergebnissen sehr populär. Das Paket “stopwords” schließlich brauchen wir, um die titelgebenden Stopwords auszuschließen - was das ist, werden wir ebenfalls weiter unten erfahren.
# falls noch nicht installiert, können Sie die benötigten
# Pakete wie folgt installieren (das # am Zeilenanfang
# entfernen, um den Code ausführen zu können):
# install.packages("tidyverse")
# install.packages("pdftools")
# install.packages("tidytext")
# install.packages("pdftools")
# install.packages("stopwords")
# install.packages("DT") # für Darstellung der Tabellen
# packages ----------------------------------------------------------------
library(tidyverse)
library(tidytext)
library(pdftools)
library(wordcloud)
library(stopwords)
library(DT)Das Wichtigste zuerst: Wir müssen die Texte der einzelnen Programme einlesen. Die Programme liegen zumeist im PDF-Format vor. Einzig bei der Linken findet sich (Stand 09.07.2021) kein PDF, daher nehmen wir mit der HTML-Variante vorlieb (die ohnehin einfacher einzulesen ist).
spd_text <- pdftools::pdf_text("https://www.spd.de/fileadmin/Dokumente/Beschluesse/Programm/SPD-Zukunftsprogramm.pdf")
gruene_text <- pdftools::pdf_text("https://cms.gruene.de/uploads/documents/Vorlaeufiges-Wahlprogramm_GRUENE-Bundestagswahl-2021.pdf")
cdu_text <- pdftools::pdf_text("https://www.ein-guter-plan-fuer-deutschland.de/programm/CDU_Beschluss%20Regierungsprogramm.pdf")
fdp_text <- pdf_text("https://www.fdp.de/sites/default/files/2021-06/FDP_Programm_Bundestagswahl2021_1.pdf")
afd_text <- pdf_text("https://cdn.afd.tools/wp-content/uploads/sites/111/2021/06/20210611_AfD_Programm_2021.pdf")
linke <- readLines("https://www.die-linke.de/wahlen/wahlprogramm-2021/")Für das Einlesen von HTML-Dateien gibt es alternativ die Möglichkeit, mit Trafilatura (Barbaresi 2021) zu arbeiten. Trafilatura hat den großen Vorteil, dass es die Möglichkeit bietet, all das unerwünschte “Beiwerk”, das wir beim Einlesen von HTML-Dateien mit crawlen (also z.B. HTML-Codes) einigermaßen zuverlässig automatisch auszufiltern. Wie das in R geht, ist in der Dokumentation des Tools genauer nachzulesen; um im Schnelldurchlauf zu sehen, wie wir Trafilatura zum Einlesen des Wahlprogramms der Linken verwenden können, klicken Sie hier:
Wir brauchen zusätzlich das Paket reticulate, das eine Schnittstelle für Python bietet. Natürlich funktioniert der Code nur, wenn auch Trafilatura installiert ist (wie das geht, ist der oben verlinkten Dokumentation zu entnehmen) – daher arbeite ich im restlichen Tutorial einfach mit dem readLines-Befehl.
library(reticulate)
trafilatura <- import("trafilatura")
downloaded <- trafilatura$fetch_url("https://www.die-linke.de/wahlen/wahlprogramm-2021/")
linke_trafi <- trafilatura$extract(downloaded)
Der Text befindet sich nun im Objekt linke_trafi.
Nun haben wir die Wahlprogramme in jeweils einem Objekt gespeichert, das nach den einzelnen Parteien benannt ist. Streng genommen wäre es sinnvoll, diese Daten noch zu bereinigen, da beispielsweise das Linken-Programm, weil wir es aus der HTML-Seite extrahiert haben, noch HTML-Markup und sog. Boilerplate-Text enthält, also Text, der auf praktisch allen (Unter-)Seiten wiederverwendet wird.
head(linke_text, 10)## [1] "<!DOCTYPE html>"
## [2] "<html lang=\"de\" dir=\"ltr\" class=\"no-js\">"
## [3] "<head>"
## [4] ""
## [5] "<meta charset=\"utf-8\">"
## [6] "<!-- "
## [7] "\tBased on the TYPO3 Bootstrap Package by Benjamin Kott - https://www.bootstrap-package.com/"
## [8] ""
## [9] "\tThis website is powered by TYPO3 - inspiring people to share!"
## [10] "\tTYPO3 is a free open source Content Management Framework initially created by Kasper Skaarhoj and licensed under GNU/GPL."
Ähnlich gibt es bei den anderen Programmen z.B. Seitenzahlen, die wir bei einer sorgfältigeren Analyse tilgen würden. Auch wäre es sinnvoll, statt mit den Wortformen mit Lemmas (also Grundformen von Wörtern) zu arbeiten – dafür müssten wir aber mit Lemmatisierung oder zumindest mit Stemming arbeiten. Wer sich zur Lemmatisierung (und zum Wortartentagging) mit Hilfe des verbreiteten Tools TreeTagger näher einlesen möchte, findet bei Noah Bubenhofer weiterführende Informationen. Mit TagAnt von Laurence Anthony gibt es mittlerweile auch eine grafische Benutzeroberfläche für den TreeTagger.
Für unsere illustrative Analyse wollen wir uns aber mit den hier vorliegenden unsauberen Datensätzen begnügen und sie nur minimal weiter bereinigen (s.u.).
Für die Analyse bieten sich nun mehrere Optionen an. Bei einer Keyword-Analyse geht es darum, herauszufinden, welche Wörter für einen gegegebenen Datensatz besonders charakteristisch sind. Dafür braucht man natürlich einen Vergleichsdatensatz. Wir werden im Folgenden die Wortfrequenzliste des Digitalen Wörterbuchs der Deutschen Sprache verwenden. Diese lässt sich über das Tool “Dstar” mit der Suchanfrage count(* #sep) erstellen. Wir lesen sie hier ein:
dwds <- read_delim("dwds_kern_frequency_list.txt", delim = "\t", quote = "", col_names = c("Freq", "Token"))##
## ── Column specification ────────────────────────────────────────────────────────
## cols(
## Freq = col_double(),
## Token = col_character()
## )
Außer mit einzelnen Wörtern können wir auch mit N-Grammen arbeiten. N-Gramme sind Wortfolgen von jeweils N Wörtern – ein Satz wie Das Pferd frisst keinen Gurkensalat lässt sich zerlegen in die Bigramme (2-Gramme) Das Pferd, frisst keinen, keinen Gurkensalat oder in die Trigramme (3-Gramme) Das Pferd frisst, Pferd frisst keinen, frisst keinen Gurkensalat. Das Prinzip dabei ist ähnlich wie bei der Keyword-Analyse auf Einzelwortebene: Wir wollen wissen, welche Wortfolgen besonders häufig vorkommen. Für die n-Gramm-Analyse werden wir allerdings keine existierende N-Gramm-Liste als Vergleichsdatensatz verwenden, sondern vielmehr die N-Gramm-Listen der einzelnen Parteien miteinander vergleichen.
Um zunächst die Einzelwortfrequenzen der Parteiprogramme mit der DWDS-Frequenzliste vergleichen zu können, müssen wir zunächst eine Tabelle mit den Wortfrequenzen in den einzelnen Dokumenten erstellen - man spricht hier manchmal auch von einer term-document matrix. Bevor wir das tun, bereinigen wir die Daten zunächst noch, indem wir die Interpunktion, Zeilenumbrüche etc. entfernen. Außerdem heben wir Groß- und Kleinschreibung auf, um die Daten besser analysieren zu können. Daraufhin nutzen wir die strsplit-Funktion, mit denen man, wie ihr Name verrät, Strings splitten kann, um an die einzelnen Wörter (definiert als das, was zwischen zwei Leerzeichen steht) zu gelangen. Dafür splitten wir den Text an den Leerzeichen auf. Wir erhalten dann einen sehr großen Vektor mit Einzelwörtern, die wir daraufhin mit der table-Funktion auszählen und in einen Dataframe überführen. Das Ganze gießen wir in eine Funktion, die wir auf die einzelnen Parteiprogramme anwenden können.
count_tokens <- function(d) {
# Interpunktion und Zeilenumbrüche weg:
d <- gsub("[[:punct:]]|\n", " ", d)
# Tabstopps und mehrere Leerzeichen durch einfache Leerzeichen ersetzen:
d <- gsub("\t| +", " ", d)
# Groß- und Kleinschreibung entfernen:
d <- tolower(d)
# an Leerzeichen splitten, um an die Einzelwörter zu kommen:
d <- unlist(strsplit(d, " "))
# Wörter auszählen:
d <- table(d) %>% as.data.frame %>% arrange(desc(Freq))
# Spaltennamen umbenennen:
colnames(d) <- c("Token", "Freq")
return(d)
}
# Funktion auf die einzelnen Wahlprogramme anwenden:
fdp <- count_tokens(fdp_text)
spd <- count_tokens(spd_text)
afd <- count_tokens(afd_text)
gruene <- count_tokens(gruene_text)
linke <- count_tokens(linke_text)
cdu <- count_tokens(cdu_text)Nun haben wir also Frequenzlisten für die einzelnen Parteien. Ein kurzer Blick in die häufigsten Wörter zeigt, dass sie uns nicht viel sagen, weil es sich zum großen Teil um dieselben Funktionswörter handelt:
head(fdp)head(spd)(Auf den ersten Blick auffällig ist lediglich die Häufigkeit von werden im SPD-Programm, das sie wohl nicht umsonst als “Zukunftsprogramm” betitelt hat.)
Wir wollen aber wissen, welche Wörter für die Wahlprogramme charakteristisch sind, welche also deutlich häufiger auftreten als in einem Vergleichsdatensatz.
Um Keywords herausfinden zu können, stützen wir uns auf Assoziationsmaße. Davon gibt es eine ganze Menge (Evert 2005 gibt einen Überblick), wir benutzen zwei: Log-Likelihood ratio und den Sørensen–Dice-Koeffizienten, oft auch nur Dice-Koeffizient genannt. Für beide Koeffizienten brauchen wir zunächst Funktionen. Die Funktionen habe ich aus Material übernommen, das meine Erlanger Kollegen Andreas Blombach und Phillip Heinrich für ein Seminar erarbeitet haben und das mit einiger Wahrscheinlichkeit auch Eingang in ein gemeinsames Buchprojekt finden wird, über das ich hoffentlich an anderer Stelle bald mehr berichten kann.
# diese Hilfsfunktion berechnet die erwarteten Frequenzen:
exp2x2 <- function(observed) {
return(matrix(
c(
sum(observed[1,]) * sum(observed[,1]) / sum(observed),
sum(observed[2,]) * sum(observed[,1]) / sum(observed),
sum(observed[1,]) * sum(observed[,2]) / sum(observed),
sum(observed[2,]) * sum(observed[,2]) / sum(observed)
),
ncol = 2
))
}
# diese Funktion berechnet die Log-Likelihood Ratio:
llr <- Vectorize(function(freq1, freq2, corpus_size1, corpus_size2) {
observed <- matrix(c(freq1, corpus_size1 - freq1,
freq2, corpus_size2 - freq2),
ncol = 2)
expected <- exp2x2(observed)
return(2 * sum(ifelse(observed > 0, observed * log(observed / expected), 0)))
})dice <- Vectorize(function(freq1, freq2, corpus_size1, corpus_size2) {
observed <- matrix(c(freq1, corpus_size1 - freq1,
freq2, corpus_size2 - freq2),
ncol = 2)
return(2 * observed[1, 1] / sum(observed[1, 1], observed[1, 2],
observed[1, 1], observed[2, 1]))
})Die eben erstellten Funktionen llr und dice nehmen beide vier Argumente, also Variablen, mit denen wir sie “füttern” müssen: Die Frequenz der einzelnen Tokens in unserem Korpus (dem jeweiligen Wahlprogramm) und im Vergleichskorpus sowie die Gesamtfrequenz aller Tokens in den beiden Korpora. Um Letztere greifbar zu haben, erstellen wir sie, indem wir einfach die Frequenzen in den entsprechenden Dataframes aufsummieren:
# Wahlprogramme:
gruene_size <- sum(gruene$Freq)
spd_size <- sum(spd$Freq)
fdp_size <- sum(fdp$Freq)
cdu_size <- sum(cdu$Freq)
afd_size <- sum(afd$Freq)
linke_size <- sum(linke$Freq)
# DWDS:
dwds_size <- sum(dwds$Freq)Nun können wir die LLR- bzw. Dice-Werte bekommen, indem wir die entsprechenden Werte einfach in die oben definierten Funktionen einsetzen. Wir nutzen hier die left_join-Funktion aus der Tidyverse-Paketfamilie, um zunächst an das jeweilige Parteiprogramm eine Spalte anzuhängen, die die DWDS-Gesamtfrequenzen aus dem dwds-Dataframe enthält, und dann die mutate-Funktion, um die LLR- und Dice-Werte einfach als weitere Spalten an die Wahlprogramme anzuhängen. Als Beispiel wählen wir einfach die alphabetisch erste der demokratisch ausgerichteten Parteien:
# Spaltennamen ändern, um Gesamtfrequenz von FDP-Frequenz unterscheiden zu können:
colnames(cdu) <- c("Token", "Freq_cdu")
# DWDS-Frequenzen hinzufügen:
cdu <- left_join(cdu, dwds)## Joining, by = "Token"
# NAs durch 0 ersetzen
cdu <- replace_na(cdu, list(Freq = 0, Freq_cdu = 0))
# Spalte mit Log-Likelihood hinzufügen:
cdu <- cdu %>% mutate(LLR = llr(cdu$Freq_cdu, cdu$Freq, cdu_size, dwds_size))
# Spalte mit Dice hinzufügen:
cdu <- cdu %>% mutate(Dice = dice(cdu$Freq_cdu, cdu$Freq, cdu_size, dwds_size)) %>% arrange(desc(LLR))
# Top 10, nach LLR sortiert (über arrange(desc(LLR)), s.o.)
cdu %>% head(10)# nach Dice sortieren:
cdu %>% arrange(desc(Dice)) %>% head(10)Dieses erste Ergebnis ist schon deutlich aufschlussreicher als die reine Tokenfrequenz, die wir oben angeschaut haben: Die Freien Demokraten wollen irgendetwas und fordern, dass Menschen - oder so. Und irgendwas mit EU. Schon die ersten zehn Keywords verraten relativ viel über zweierlei: Zum einen über die inhaltlichen Themen des Wahlprogramms, zum anderen - und das kommt zumindest bei den Top 10 noch etwas stärker zum Tragen - über die rhetorischen Strategien, die im Wahlprogramm angewandt werden.
Da wir das nun auch für die anderen Parteien machen wollen, lohnt es sich, das Ganze wieder in eine Funktion zu gießen und dann auf alle Parteien anzuwenden:
# Funktion:
association_measures <- function(df) {
# welche Partei ist gerade dran? mit deparse(substitute())
# erhalten wir den Namen der Partei als character string:
partei <- deparse(substitute(df))
# das ist wichtig, weil wir die size-Variable der
# entsprechenden Partei brauchen.
df_size <- get(paste0(partei, "_size"))
# Spaltennamen ändern, um Gesamtfrequenz von df-Frequenz unterscheiden zu können:
colnames(df) <- c("Token", "Freq_df")
# DWDS-Frequenzen hinzufügen:
df <- left_join(df, dwds)
# NAs durch 0 ersetzen
df <- replace_na(df, list(Freq = 0, Freq_df = 0))
# Spalten mit Log-Likelihood, Dice und p-Wert hinzufügen:
df <- df %>% mutate(LLR = llr(df$Freq_df, df$Freq, df_size, dwds_size),
Dice = dice(df$Freq_df, df$Freq, df_size, dwds_size),
p = pchisq(LLR, df = 1, lower.tail = FALSE)) %>% arrange(desc(LLR))
# ausgeben
return(df)
}
# auf die einzelnen Parteien angewendet:
spd <- association_measures(spd)## Joining, by = "Token"
fdp <- association_measures(fdp)## Joining, by = "Token"
linke <- association_measures(linke)## Joining, by = "Token"
afd <- association_measures(afd)## Joining, by = "Token"
gruene <- association_measures(gruene)## Joining, by = "Token"
Wir wollen nun die Daten mit Hilfe von Wortwolken visualisieren, wobei die Größe der Wörter mit der LLR korrelieren soll. Da so eine Wolke schnell sehr unübersichtlich wird, beschränken wir uns auf die 200 Treffer mit der höchsten LLR - da wir die Daten oben nach LLR sortiert haben, können wir hierfür einfach die head-Funktion benutzen, die uns die ersten n Treffer ausgibt (hier: die ersten 200). Den LLR-Wert teilen wir durch 20, da die Wörter in der Darstellung sonst zu groß werden würden. Optional können wir noch sog. stopwords ausschließen (dafür die entsprechenden Zeilen auskommentieren). Das sind Wörter, die in einer Sprache sehr häufig vorkommen und daher nicht unbedingt besonders aussagekräftig sind. Hier lassen wir sie aber zunächst drin, weil auch sonst uninteressante Alltagswörter wie wir in diesem Kontext durchaus spannend sein können.
Zur Erstellung der Wortwolken benutzen wir die wordcloud-Funktion aus dem gleichnamigen Paket. Mit dem scale-Argument lässt sich die minimale und maximale Schriftgröße einstellen. Dieses Argument habe ich für die einzelnen Parteien so angepasst, dass die Wolken ungefähr die gleichen Dimensionen haben (weil sich die LLR-Werte doch teilweise sehr unterscheiden). Bei der Linken schließe ich ein paar Wörter aus, die nicht zum Text selbst, sondern zum HTML-Code gehören (z.B. div, nav etc.). Dieses Problem stellt sich nicht, wenn man mit Trafilatura gearbeitet hat (s. ausklappbarer Teil oben).
# Top 200
linke200 <- head(filter(linke, !Token %in% c("li", "strong", "div", "p", "class", "ul", "nav", "h2", "nbsp", "accordion", "href", "a", "id", "h3", "aria", "panel", "role", "h4", "56202")), 200)
gruene200 <- head(gruene, 200)
fdp200 <- head(fdp, 200)
spd200 <- head(spd, 200)
afd200 <- head(afd, 200)
cdu200 <- head(cdu, 200)# Seed setzen, damit immer die gleiche Wolke entsteht
# (da die Anordnung der Wörter zufallsgeneriert ist)
set.seed(1985)
wordcloud(words = gruene200$Token, freq = gruene200$LLR/20, col = "green", scale = c(3, .01))
wordcloud(words = afd200$Token, freq = afd200$LLR/20, col = "blue", scale = c(6, .2))
wordcloud(words = fdp200$Token, freq = fdp200$LLR/20, col = "yellow3", scale = c(5, .05))
wordcloud(words = cdu200$Token, freq = cdu200$LLR/20, col = "black", scale = c(4, .05))
wordcloud(words = linke200$Token, freq = fdp200$LLR/20, col = "darkred", scale = c(4, .01))
wordcloud(words = spd200$Token, freq = spd200$LLR/20, col = "red", scale = c(3, .01))Wortwolken der einzelnen Parteiprogramme
Es zeigen sich ein paar interessante Tendenzen: CDU, SPD, FDP und Grüne benutzen besonders häufig wir. Die Modalverben wollen und müssen kommen in praktisch allen Programmen vor, was naheliegend ist. Einige Kernthemen der Parteien lassen sich aus den Keywords ebenfalls gut herauslesen: Bei der FDP zum Beispiel Wettbewerb, Unternehmen, Digitalisierung, bei der Linken etwa Arbeitsbedingungen, bei der AfD z.B. Deutschland und Migration. Dass einige Parteien gendern, schlägt sich im Keyword innen nieder (das bei der Tokenisierung als eigenes Wort behandelt wird). menschen werden bei CDU und Grünen besonders oft erwähnt, das AfD-Programm spricht über kinder und familien, das CDU-Programm über sicherheit und zusammenarbeit.
Insgesamt geben die Keywords einen interessanten Einblick in die wesentlichen Themen der Parteiprogramme, auch wenn man natürlich ein gewisses Hintergrundwissen braucht, um sie adäquat interpretieren zu können – so findet sich beispielsweise das Keyword euro bei der Linken und bei der AfD, aber die Position beider Parteien zur gemeinsamen europäischen Währung weist bekanntlich gewisse Unterschiede auf. Daher lohnt es sich, über Einzelwörter hinauszugehen, was wir im Folgenden tun wollen.
Die einzelnen Keywords sind zwar schon recht aufschlussreich, aber gerade wenn wir die rhetorischen Strategien der Parteien auf Grundlage wiederkehrender Muster genauer untersuchen wollen, benötigen wir im Idealfall mehr als nur einzelne Wörter. Hier bieten sich N-Gramme an. Mit dem tidytext-Paket lassen sich N-Gramme sehr einfach extrahieren, was wir im Folgenden tun wollen. Mit der Funktion unnest_tokens können wir dem Dataframe eine Spalte mit N-Grammen hinzufügen – wir arbeiten im Folgenden mit Trigrammen, also Dreiwortsequenzen.
Dafür bereinigen wir die Daten erst wieder ein wenig: Wir entfernen (für die PDF-Dateien) die Worttrennung am Zeilenende und splitten den Text in Sätze auf. Dann überführen wir die einzelnen Sätze in einen Dataframe (“tibble” im Tidyverse-Jargon), was das Input-Format ist, das die unnest_tokens-Funktion benötigt. Für das Linken-Programm verwende ich hier die mit Trafilatura gecrawlte Version – im Falle der mit readLines eingelesenen Version wäre es sinnvoll, zunächst noch die vielen HTML-Tags aus dem Text zu entfernen.
# Funktion, um leere Elemente zu entfernen
no_empty <- function(vec) {
vec <- vec[which(vec!="")]
return(vec)
}
# SPD-Trigramme:
spd_trigrams <- spd_text %>%
# Worttrennung am Zeilenende ersetzen
gsub("\\-\n", "", .) %>%
# Worttrennung entfernen
gsub("\n", " ", .) %>%
# mehrere Leerzeichen durch eins ersetzen
gsub(" +", " ", .) %>%
# an Satzgrenzen aufspalten
strsplit(., split = "\\.|\\?|!") %>%
# vom Listen- ins Vektorformat überführen
unlist %>%
# Leerzeichen am Anfang und Ende der einzelnen Strings entfernen
trimws %>%
# mit der oben definierten Funktion leere Elemente im Vektor entfernen
no_empty %>%
# in Tibble überführen
as_tibble() %>%
# Trigramme
unnest_tokens(trigram, value, token="ngrams", n = 3) %>%
# NAs entfernen
na.omit()
# FDP-Trigramme:
fdp_trigrams <- fdp_text %>% gsub("\\-\n", "", .) %>% gsub("\n", " ", .) %>% gsub(" +", " ", .) %>% strsplit(., split = "\\.|\\?|!") %>% unlist %>% trimws %>% no_empty %>% as_tibble() %>% unnest_tokens(trigram, value, token="ngrams", n = 3) %>% na.omit
# Linke-Trigramme:
linke_trigrams <- linke_trafi %>% strsplit(., split = "\\.|\\?|!") %>% unlist %>% trimws %>% no_empty %>% as_tibble() %>% unnest_tokens(trigram, value, token="ngrams", n = 3) %>% na.omit
# CDU-Trigramme:
cdu_trigrams <- cdu_text %>% gsub("\\-\n", "", .) %>% gsub("\n", " ", .) %>% gsub(" +", " ", .) %>% strsplit(., split = "\\.|\\?|!") %>% unlist %>% trimws %>% no_empty %>% as_tibble() %>% unnest_tokens(trigram, value, token="ngrams", n = 3) %>% na.omit
# AfD-Trigramme:
afd_trigrams <- afd_text %>% gsub("\\-\n", "", .) %>% gsub("\n", " ", .) %>% gsub(" +", " ", .) %>% strsplit(., split = "\\.|\\?|!") %>% unlist %>% trimws %>% no_empty %>% as_tibble() %>% unnest_tokens(trigram, value, token="ngrams", n = 3) %>% na.omit
# Grüne-Trigramme:
gruene_trigrams <- gruene_text %>% gsub("\\-\n", "", .) %>% gsub("\n", " ", .) %>% gsub(" +", " ", .) %>% strsplit(., split = "\\.|\\?|!") %>% unlist %>% trimws %>% no_empty %>% as_tibble() %>% unnest_tokens(trigram, value, token="ngrams", n = 3) %>% na.omitUm quasi die “Alleinstellungsmerkmale” der einzelnen Parteiprogramme gegenüber den anderen herauszufiltern, wollen wir die Trigramme aus jedem Programm mit denen aus allen anderen Programmen vergleichen. Dafür erstellen wir einen großen Datensatz, der in einer weiteren Spalte die Information enthält, aus welchem Programm das Trigramm stammt, sodass wir die jeweilige Partei später unkompliziert ausschließen können.
trigrams <- rbind(mutate(afd_trigrams, Partei = "afd"),
mutate(cdu_trigrams, Partei = "cdu"),
mutate(fdp_trigrams, Partei = "fdp"),
mutate(gruene_trigrams, Partei = "gruene"),
mutate(linke_trigrams, Partei = "linke"),
mutate(spd_trigrams, Partei = "spd"))Nun können wir die Trigramme wiederum auszählen. Wir erstellen eine Tabelle, die sowohl die Trigrammfrequenzen für die jeweilige Partei als auch die Gesamtfrequenz, mit der das jeweilige Trigramm in allen Wahlprogrammen auftritt, enthält.
# Trigramme auszählen, pro Parteiprogramm:
tri_count <- trigrams %>% group_by(Partei, trigram) %>% summarise(
Freq = n()
)
# Trigramme auszählen, Parteiprogrammunabhängig:
tri_all <- trigrams %>% group_by(trigram) %>% summarise(
Freq_all = n()
)
# diese Tabelle mit der oben erstellten verbinden:
tri_count <- left_join(tri_count, tri_all)Mit Hilfe dieser Tabelle können wir nun die Frequenzwerte errechnen, die wir wiederum als Input für die oben etablierten Assoziationsmaße benutzen können. Erneut benutzen wir die LLR; im untenstehenden Loop lasse ich R nacheinander die LLRs für die einzelnen Parteien berechnen. (Hinweis: Die as.numeric-Funktion verwende ich hier, weil es sonst zu einem Phänomen kommt, das sich “integer overflow” nennt. Klingt wie Durchfall und ist ähnlich lästig, aber zum Glück mit diesem kleinen Trick behebbar.)
# new LLR column for tri_count df:
tri_count$llr <- numeric(nrow(tri_count))
tri_count$p_value <- numeric(nrow(tri_count))
# get LLR for each party:
for(i in 1:length(unique(tri_count$Partei))) {
prt <- unique(tri_count$Partei)[i]
# current df:
df_not_cur <- tri_count[which(tri_count$Partei != prt),]
df_cur <- tri_count[which(tri_count$Partei == prt),]
tri_count[which(tri_count$Partei == prt),]$llr <- llr(freq1 = as.numeric(df_cur$Freq),
freq2 = as.numeric(df_cur$Freq_all),
corpus_size1 = sum(df_cur$Freq),
corpus_size2 = sum(df_not_cur$Freq_all))
# p-Werte hinzufügen:
tri_count[which(tri_count$Partei == prt),]$p_value <- pchisq(tri_count[which(tri_count$Partei == prt),]$llr, df = 1, lower.tail = FALSE)
}Ergänzend zur LLR können wir ein Maß der Effektstärke berechnen – denn bei den LLR-Werten können selbst kleine Frequenzunterschiede zu großen LLRs führen. Wir wählen hierfür die Odds Ratio.
# Funktion für odds ratio
odds_ratio <- Vectorize(function(freq1, freq2, corpus_size1, corpus_size2) {
observed <- matrix(c(freq1, corpus_size1 - freq1,
freq2, corpus_size2 - freq2),
ncol = 2)
return(
(observed[1,1] / observed[2,1]) / (observed[1,2] / observed[2,2])
)
})# Funktion anwenden
tri_count$odds_ratio <- numeric(nrow(tri_count))
for(i in 1:length(unique(tri_count$Partei))) {
prt = unique(tri_count$Partei)[i]
tri_count[tri_count$Partei==prt,]$odds_ratio <- odds_ratio(freq1 = filter(tri_count, Partei == prt)$Freq,
freq2 = filter(tri_count, Partei == prt)$Freq_all, corpus_size1 = nrow(filter(tri_count, Partei==prt)), corpus_size2 = nrow(filter(tri_count, Partei != prt)))
}Wir fügen außerdem noch eine Spalte hinzu, die uns erlaubt, diejenigen Trigramme, die (nur oder überwiegend) Stopwords enthalten, einfach herauszufiltern.
# Funktion, die ausgibt, ob eines von drei Wörtern in Stopwords enthalten ist:
in_stopwords <- Vectorize(function(x) {
x <- unlist(strsplit(x, " "))
return(length(which(x %in% stopwords("de"))))
})
# Funktion benutzen:
tri_count$in_stopwords <- unname(in_stopwords(tri_count$trigram))Auf Grundlage der so gewonnenen Assoziationsmaße können wir erneut Wortwolken erstellen, denen wir die am häufigsten vorkommenden Dreiwortfolgen der Parteiprogramme entnehmen können. Zunächst mit allen Trigrammen…
# Wir schließen die Trigramme aus,
# die aus Boilerplate-ähnlichem Material bestehen
tri_count <- tri_count[which(!tri_count$trigram %in% c("parteivorstand 2021 seite",
"der spd kapitel", "spd parteivorstand 2021", "zukunftsprogramm der spd", "das zukunftsprogramm der", grep("(^| )kapitel( |$)|(^| )seite( |$)", tri_count$trigram, value = T))),]
# Wir schließen außerdem alle Trigramme aus, die nur aus Ziffern bestehen (bei der AfD sehr häufig, wohl wg. Seitenzahlen)
tri_count <- tri_count[grep("^([[:digit:]]| )+$", tri_count$trigram, invert = T),]set.seed(1985)
wordcloud(filter(tri_count, Partei == "spd")$trigram,
filter(tri_count, Partei == "spd")$llr,
scale = c(1.2, .01), colors = "red")
wordcloud(filter(tri_count, Partei == "linke")$trigram,
filter(tri_count, Partei == "linke")$llr,
scale = c(1.5, .01), colors = "darkred")
wordcloud(filter(tri_count, Partei == "fdp")$trigram,
filter(tri_count, Partei == "fdp")$llr,
scale = c(4, .1), colors = "yellow3")
wordcloud(filter(tri_count, Partei == "cdu")$trigram,
filter(tri_count, Partei == "cdu")$llr,
scale = c(4, .1), colors = "black")
wordcloud(filter(tri_count, Partei == "gruene")$trigram,
filter(tri_count, Partei == "gruene")$llr,
scale = c(1.5, .01), colors = "green")
wordcloud(filter(tri_count, Partei == "afd")$trigram,
filter(tri_count, Partei == "afd")$llr,
scale = c(2, .01), colors = "blue")